Описание проекта: Я маркетинговый аналитик развлекательного приложения Procrastinate Pro+. Компания терпит убытки, несмотря на огромные вложения в рекламу.
Описание данных: В моем распоряжении три датасета:
Цель исследования: Разобраться в причинах неэффективности привлечения пользователей, помочь компании выйти в плюс и сформулировать рекомендации для отдела маркетинга.
Ход исследования:
Общий вывод: резюмирование полученных результатов, формулировка ключевых выводов и рекомендаций.
С помощью данного исследования мы стремимся дать всестороннний анализ прользователей развлекательного приложения Procrastinate Pro+, что станет отправной точкой для дальнейшего развития и планирования рекламных расходов компании.
# импортируем библиотеки pandas, matplot, numpy, scipy
import pandas as pd
import numpy as np
import datetime as dt
from datetime import datetime, timedelta
from matplotlib import pyplot as plt
# устанавливаем отображение количества столбцов
pd.options.display.max_columns = 40
# устанавливаем отображение полного текста в ячейке
pd.set_option('display.max_colwidth', None)
# устанавливаем отображение чисел с двумя знаками после запятой
pd.set_option('display.float_format', '{:.2f}'.format)
# открываем файл
visits = pd.read_csv('/Users/ildushisamov/Desktop/projects/анализ бизнес показателей/visits_info_short.csv')
orders = pd.read_csv('/Users/ildushisamov/Desktop/projects/анализ бизнес показателей/orders_info_short.csv')
costs = pd.read_csv('/Users/ildushisamov/Desktop/projects/анализ бизнес показателей/costs_info_short.csv')
def my_func(x):
print('-'*15, 'Исходный датафрейм', '-'*15)
display(x.head())
print('')
print('')
print('-'*15, 'Общая информация о датафрейме', '-'*15)
print('')
print('')
x.info()
print('-'*15, 'Количество явных дубликатов в датафрейме', '-'*15)
display(x.duplicated().sum())
print('')
print('')
print('-'*15, 'Описательная статистика', '-'*15)
display(x.describe())
my_func(visits)
--------------- Исходный датафрейм ---------------
| User Id | Region | Device | Channel | Session Start | Session End | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | United States | iPhone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | United States | iPhone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | United States | Mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | United States | Android | TipTop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | United States | Mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
--------------- Общая информация о датафрейме --------------- <class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 309901 non-null int64 1 Region 309901 non-null object 2 Device 309901 non-null object 3 Channel 309901 non-null object 4 Session Start 309901 non-null object 5 Session End 309901 non-null object dtypes: int64(1), object(5) memory usage: 14.2+ MB --------------- Количество явных дубликатов в датафрейме ---------------
0
--------------- Описательная статистика ---------------
| User Id | |
|---|---|
| count | 309901.00 |
| mean | 499766449382.70 |
| std | 288789916887.83 |
| min | 599326.00 |
| 25% | 249369122776.00 |
| 50% | 498990589687.00 |
| 75% | 749521111616.00 |
| max | 999999563947.00 |
В таблице 309901 записей (вместе с шапкой таблицы) и 6 колонок:
Предобработка:
# поменяем названия колонок
visits.columns = [x.lower().replace(' ', '_') for x in visits.columns]
# поменяем типы данных
visits['session_start'] = pd.to_datetime(visits['session_start'])
visits['session_end'] = pd.to_datetime(visits['session_end'])
# проверка
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 309901 non-null int64 1 region 309901 non-null object 2 device 309901 non-null object 3 channel 309901 non-null object 4 session_start 309901 non-null datetime64[ns] 5 session_end 309901 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(1), object(3) memory usage: 14.2+ MB
my_func(orders)
--------------- Исходный датафрейм ---------------
| User Id | Event Dt | Revenue | |
|---|---|---|---|
| 0 | 188246423999 | 2019-05-01 23:09:52 | 4.99 |
| 1 | 174361394180 | 2019-05-01 12:24:04 | 4.99 |
| 2 | 529610067795 | 2019-05-01 11:34:04 | 4.99 |
| 3 | 319939546352 | 2019-05-01 15:34:40 | 4.99 |
| 4 | 366000285810 | 2019-05-01 13:59:51 | 4.99 |
--------------- Общая информация о датафрейме --------------- <class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 40212 non-null int64 1 Event Dt 40212 non-null object 2 Revenue 40212 non-null float64 dtypes: float64(1), int64(1), object(1) memory usage: 942.6+ KB --------------- Количество явных дубликатов в датафрейме ---------------
0
--------------- Описательная статистика ---------------
| User Id | Revenue | |
|---|---|---|
| count | 40212.00 | 40212.00 |
| mean | 499029531203.23 | 5.37 |
| std | 286093675967.16 | 3.45 |
| min | 599326.00 | 4.99 |
| 25% | 251132440436.75 | 4.99 |
| 50% | 498283972665.00 | 4.99 |
| 75% | 743332711780.00 | 4.99 |
| max | 999895427370.00 | 49.99 |
# Проверим цифры в колонке Revenue
orders['Revenue'].sort_values().unique()
array([ 4.99, 5.99, 9.99, 19.99, 49.99])
В таблице 40212 записей (вместе с шапкой таблицы) и 3 колонки:
Предобработка:
# поменяем названия колонок
orders.columns = [x.lower().replace(' ', '_') for x in orders.columns]
# поменяем типы данных
orders['event_dt'] = pd.to_datetime(orders['event_dt'])
# проверка
orders.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 40212 non-null int64 1 event_dt 40212 non-null datetime64[ns] 2 revenue 40212 non-null float64 dtypes: datetime64[ns](1), float64(1), int64(1) memory usage: 942.6 KB
my_func(costs)
--------------- Исходный датафрейм ---------------
| dt | Channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.30 |
| 1 | 2019-05-02 | FaceBoom | 78.10 |
| 2 | 2019-05-03 | FaceBoom | 85.80 |
| 3 | 2019-05-04 | FaceBoom | 136.40 |
| 4 | 2019-05-05 | FaceBoom | 122.10 |
--------------- Общая информация о датафрейме --------------- <class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 Channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB --------------- Количество явных дубликатов в датафрейме ---------------
0
--------------- Описательная статистика ---------------
| costs | |
|---|---|
| count | 1800.00 |
| mean | 58.61 |
| std | 107.74 |
| min | 0.80 |
| 25% | 6.50 |
| 50% | 12.29 |
| 75% | 33.60 |
| max | 630.00 |
# Проверим цифры в колонке costs
costs['costs'].sort_values().unique()
array([ 0.8 , 1.26 , 1.44 , 1.6 , 1.62 , 1.8 , 1.92 ,
1.98 , 2.16 , 2.34 , 2.4 , 2.4 , 2.52 , 2.64 ,
2.7 , 2.73 , 2.73 , 2.75 , 2.88 , 2.925, 2.94 ,
3. , 3.06 , 3.12 , 3.15 , 3.2 , 3.24 , 3.36 ,
3.42 , 3.5 , 3.57 , 3.575, 3.6 , 3.75 , 3.78 ,
3.84 , 3.9 , 3.96 , 3.99 , 4. , 4.08 , 4.095,
4.14 , 4.2 , 4.225, 4.25 , 4.32 , 4.41 , 4.5 ,
4.55 , 4.56 , 4.62 , 4.68 , 4.75 , 4.8 , 4.8 ,
4.83 , 4.86 , 5. , 5.04 , 5.13 , 5.22 , 5.25 ,
5.28 , 5.4 , 5.4 , 5.46 , 5.46 , 5.5 , 5.52 ,
5.525, 5.58 , 5.6 , 5.67 , 5.75 , 5.76 , 5.85 ,
5.88 , 5.915, 5.94 , 6. , 6.175, 6.24 , 6.25 ,
6.3 , 6.37 , 6.4 , 6.435, 6.48 , 6.48 , 6.5 ,
6.51 , 6.6 , 6.72 , 6.75 , 6.825, 6.825, 6.9 ,
6.96 , 7.02 , 7.14 , 7.15 , 7.2 , 7.2 , 7.28 ,
7.29 , 7.35 , 7.44 , 7.475, 7.5 , 7.56 , 7.605,
7.68 , 7.735, 7.77 , 7.8 , 7.8 , 7.92 , 7.98 ,
8. , 8.1 , 8.125, 8.16 , 8.19 , 8.19 , 8.25 ,
8.37 , 8.4 , 8.45 , 8.5 , 8.61 , 8.64 , 8.645,
8.75 , 8.775, 8.8 , 8.82 , 8.88 , 9. , 9.03 ,
9.1 , 9.24 , 9.25 , 9.3 , 9.36 , 9.425, 9.45 ,
9.5 , 9.555, 9.6 , 9.66 , 9.75 , 9.84 , 9.87 ,
9.9 , 9.945, 10. , 10.01 , 10.075, 10.08 , 10.2 ,
10.25 , 10.29 , 10.4 , 10.465, 10.5 , 10.53 , 10.71 ,
10.75 , 10.8 , 10.92 , 10.92 , 11. , 11.04 , 11.05 ,
11.07 , 11.1 , 11.115, 11.13 , 11.2 , 11.25 , 11.28 ,
11.34 , 11.375, 11.375, 11.4 , 11.5 , 11.52 , 11.55 ,
11.7 , 11.75 , 11.76 , 11.97 , 12. , 12.18 , 12.25 ,
12.285, 12.285, 12.3 , 12.39 , 12.5 , 12.6 , 12.74 ,
12.75 , 12.8 , 12.81 , 12.87 , 13. , 13.02 , 13.195,
13.23 , 13.25 , 13.44 , 13.455, 13.5 , 13.6 , 13.65 ,
13.75 , 13.8 , 13.86 , 14. , 14.07 , 14.105, 14.25 ,
14.28 , 14.4 , 14.5 , 14.56 , 14.625, 14.7 , 14.75 ,
14.91 , 15. , 15.2 , 15.21 , 15.25 , 15.33 , 15.47 ,
15.5 , 15.54 , 15.6 , 15.75 , 15.795, 15.96 , 16. ,
16.17 , 16.25 , 16.38 , 16.38 , 16.5 , 16.75 , 16.8 ,
16.965, 17. , 17.43 , 17.5 , 17.55 , 17.6 , 17.75 ,
17.85 , 18. , 18.135, 18.4 , 18.5 , 18.69 , 18.72 ,
18.75 , 18.9 , 19. , 19.2 , 19.2 , 19.25 , 19.8 ,
19.89 , 19.95 , 20. , 20.4 , 20.475, 21. , 21.06 ,
21.25 , 21.5 , 21.6 , 21.645, 22.05 , 22.2 , 22.23 ,
22.75 , 22.8 , 22.815, 23.1 , 23.4 , 24. , 24.15 ,
24.57 , 24.6 , 25.2 , 25.2 , 25.8 , 26.25 , 26.4 ,
27. , 27.3 , 27.495, 27.6 , 28.2 , 28.35 , 28.8 ,
29.4 , 30. , 30.45 , 30.6 , 31.2 , 31.5 , 31.8 ,
32.4 , 32.55 , 33. , 33.6 , 34.2 , 34.65 , 34.8 ,
35.4 , 35.7 , 36. , 36.6 , 36.75 , 37.2 , 37.8 ,
38.4 , 38.85 , 39. , 39.6 , 39.9 , 40.2 , 40.8 ,
40.95 , 41.4 , 42. , 42.6 , 43.05 , 43.2 , 43.8 ,
44.1 , 44.4 , 45. , 45.6 , 46. , 46.2 , 46.8 ,
47.25 , 48.6 , 49. , 49.2 , 51.45 , 52.2 , 53.4 ,
53.55 , 55. , 56. , 56.7 , 57. , 58. , 59. ,
60. , 61. , 64. , 70. , 74. , 77. , 78. ,
78.1 , 80. , 82. , 82.5 , 83. , 84. , 85.8 ,
88. , 90.2 , 91.3 , 92.4 , 93.5 , 96.9 , 97.9 ,
99. , 100.1 , 101.2 , 102.6 , 104.5 , 105.6 , 107.8 ,
108.9 , 110. , 110.2 , 113.3 , 114.4 , 115.5 , 115.9 ,
117.7 , 118.8 , 122.1 , 123.2 , 124.3 , 127.3 , 128.7 ,
129.2 , 131.1 , 132. , 133. , 133.1 , 134.2 , 135.3 ,
136.4 , 138.6 , 140.8 , 141.9 , 142.5 , 143. , 144.1 ,
145.2 , 146.3 , 147.4 , 148.5 , 150.1 , 152.9 , 153.9 ,
154. , 155.1 , 155.8 , 156.2 , 158.4 , 159.5 , 159.6 ,
160.6 , 161.7 , 163.4 , 163.9 , 167.2 , 168.3 , 169.4 ,
170.5 , 171.6 , 172.7 , 172.9 , 173.8 , 174.9 , 176. ,
176.7 , 176.8 , 178.2 , 178.6 , 179.3 , 180.4 , 182.4 ,
182.6 , 183.7 , 184.3 , 186.2 , 187.2 , 188.1 , 189.2 ,
190. , 191.4 , 192.5 , 193.6 , 194.7 , 195.7 , 195.8 ,
198. , 200.2 , 201.3 , 202.8 , 203.3 , 203.5 , 204.6 ,
205.7 , 206.8 , 207.9 , 208. , 210.1 , 210.6 , 210.9 ,
211.2 , 212.8 , 213.2 , 213.4 , 214.5 , 216.7 , 217.8 ,
218.9 , 221.1 , 222.2 , 223.6 , 225.5 , 227.7 , 228.8 ,
229.9 , 231.4 , 232.1 , 233.2 , 234.3 , 235.4 , 236.5 ,
236.6 , 237.6 , 238.7 , 240.9 , 242. , 243.1 , 248.6 ,
249.6 , 250.8 , 252.2 , 255.2 , 258.4 , 258.5 , 259.6 ,
260.7 , 261.8 , 262.2 , 262.9 , 264. , 265.2 , 270.4 ,
272.8 , 273. , 283.1 , 286. , 286.9 , 288.6 , 290.4 ,
294.8 , 295.9 , 299.2 , 301.6 , 303. , 306. , 308. ,
309.4 , 311.5 , 312. , 315. , 317.2 , 318. , 321. ,
322. , 322.4 , 324. , 325. , 325.5 , 330.2 , 332.8 ,
335.4 , 336. , 338. , 339. , 342. , 343. , 343.2 ,
345. , 345.8 , 351. , 353.5 , 354. , 366. , 371. ,
375. , 378. , 381. , 381.5 , 384. , 387. , 387.4 ,
388.5 , 390. , 396. , 399. , 402.5 , 405. , 405.6 ,
409.5 , 413. , 417. , 420. , 437.5 , 448. , 450. ,
451.5 , 455. , 458.5 , 472.5 , 476. , 479.5 , 483. ,
514.5 , 518. , 528.5 , 535.5 , 542.5 , 553. , 556.5 ,
563.5 , 570.5 , 588. , 602. , 605.5 , 616. , 623. ,
630. ])
Все цифры вещественные.
В таблице 1800 записей (вместе с шапкой таблицы) и 3 колонки:
Предобработка:
# поменяем названия колонок
costs.columns = [x.lower().replace(' ', '_') for x in costs.columns]
# поменяем типы данных
costs['dt'] = pd.to_datetime(costs['dt']).dt.date
# проверка
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
Разрешается использовать функции, с которыми вы познакомились в теоретических уроках.
Это функции для вычисления значений метрик:
get_profiles() — для создания профилей пользователей,get_retention() — для подсчёта Retention Rate,get_conversion() — для подсчёта конверсии,get_ltv() — для подсчёта LTV.А также функции для построения графиков:
filter_data() — для сглаживания данных,plot_retention() — для построения графика Retention Rate,plot_conversion() — для построения графика конверсии,plot_ltv_roi — для визуализации LTV и ROI.def get_profiles(sessions, orders, ad_costs):
# находим параметры первых посещений
profiles = (
sessions.sort_values(by=['user_id', 'session_start'])
.groupby('user_id')
.agg(
{
'session_start': 'first',
'channel': 'first',
'device': 'first',
'region': 'first',
}
)
.rename(columns={'session_start': 'first_ts'})
.reset_index()
)
# для когортного анализа определяем дату первого посещения
# и первый день месяца, в который это посещение произошло
profiles['dt'] = profiles['first_ts'].dt.date
profiles['month'] = profiles['first_ts'].dt.month
# добавляем признак платящих пользователей
profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())
# считаем количество уникальных пользователей
# с одинаковыми источником и датой привлечения
new_users = (
profiles.groupby(['dt', 'channel'])
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'unique_users'})
.reset_index()
)
# объединяем траты на рекламу и число привлечённых пользователей
ad_costs = ad_costs.merge(new_users, on=['dt', 'channel'], how='left')
# делим рекламные расходы на число привлечённых пользователей
ad_costs['acquisition_cost'] = ad_costs['costs'] / ad_costs['unique_users']
# добавляем стоимость привлечения в профили
profiles = profiles.merge(
ad_costs[['dt', 'channel', 'acquisition_cost']],
on=['dt', 'channel'],
how='left',
)
# стоимость привлечения органических пользователей равна нулю
profiles['acquisition_cost'] = profiles['acquisition_cost'].fillna(0)
return profiles
def get_retention(
profiles,
sessions,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# добавляем столбец payer в передаваемый dimensions список
dimensions = ['payer'] + dimensions
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# собираем «сырые» данные для расчёта удержания
result_raw = result_raw.merge(
sessions[['user_id', 'session_start']], on='user_id', how='left'
)
result_raw['lifetime'] = (
result_raw['session_start'] - result_raw['first_ts']
).dt.days
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу удержания
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# получаем таблицу динамики удержания
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
def get_conversion(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# определяем дату и время первой покупки для каждого пользователя
first_purchases = (
purchases.sort_values(by=['user_id', 'event_dt'])
.groupby('user_id')
.agg({'event_dt': 'first'})
.reset_index()
)
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
first_purchases[['user_id', 'event_dt']], on='user_id', how='left'
)
# рассчитываем лайфтайм для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
result = result.fillna(0).cumsum(axis = 1)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# делим каждую «ячейку» в строке на размер когорты
# и получаем conversion rate
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу конверсии
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# для таблицы динамики конверсии убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицу динамики конверсии
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
def get_ltv(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
purchases[['user_id', 'event_dt', 'revenue']], on='user_id', how='left'
)
# рассчитываем лайфтайм пользователя для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция группировки по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
# строим «треугольную» таблицу выручки
result = df.pivot_table(
index=dims, columns='lifetime', values='revenue', aggfunc='sum'
)
# находим сумму выручки с накоплением
result = result.fillna(0).cumsum(axis=1)
# вычисляем размеры когорт
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
# объединяем размеры когорт и таблицу выручки
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# считаем LTV: делим каждую «ячейку» в строке на размер когорты
result = result.div(result['cohort_size'], axis=0)
# исключаем все лайфтаймы, превышающие горизонт анализа
result = result[['cohort_size'] + list(range(horizon_days))]
# восстанавливаем размеры когорт
result['cohort_size'] = cohort_sizes
# собираем датафрейм с данными пользователей и значениями CAC,
# добавляя параметры из dimensions
cac = df[['user_id', 'acquisition_cost'] + dims].drop_duplicates()
# считаем средний CAC по параметрам из dimensions
cac = (
cac.groupby(dims)
.agg({'acquisition_cost': 'mean'})
.rename(columns={'acquisition_cost': 'cac'})
)
# считаем ROI: делим LTV на CAC
roi = result.div(cac['cac'], axis=0)
# удаляем строки с бесконечным ROI
roi = roi[~roi['cohort_size'].isin([np.inf])]
# восстанавливаем размеры когорт в таблице ROI
roi['cohort_size'] = cohort_sizes
# добавляем CAC в таблицу ROI
roi['cac'] = cac['cac']
# в финальной таблице оставляем размеры когорт, CAC
# и ROI в лайфтаймы, не превышающие горизонт анализа
roi = roi[['cohort_size', 'cac'] + list(range(horizon_days))]
# возвращаем таблицы LTV и ROI
return result, roi
# получаем таблицы LTV и ROI
result_grouped, roi_grouped = group_by_dimensions(
result_raw, dimensions, horizon_days
)
# для таблиц динамики убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицы динамики LTV и ROI
result_in_time, roi_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
return (
result_raw, # сырые данные
result_grouped, # таблица LTV
result_in_time, # таблица динамики LTV
roi_grouped, # таблица ROI
roi_in_time, # таблица динамики ROI
)
def filter_data(df, window):
# для каждого столбца применяем скользящее среднее
for column in df.columns.values:
df[column] = df[column].rolling(window).mean()
return df
def plot_retention(retention, retention_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
def plot_conversion(conversion, conversion_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 5))
# исключаем размеры когорт
conversion = conversion.drop(columns=['cohort_size'])
# в таблице динамики оставляем только нужный лайфтайм
conversion_history = conversion_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# первый график — кривые конверсии
ax1 = plt.subplot(1, 2, 1)
conversion.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Конверсия пользователей')
# второй график — динамика конверсии
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
columns = [
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
name for name in conversion_history.index.names if name not in ['dt']
]
filtered_data = conversion_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
# функция для визуализации LTV и ROI
def plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon, window=14):
# задаём сетку отрисовки графиков
plt.figure(figsize=(20, 10))
# из таблицы ltv исключаем размеры когорт
ltv = ltv.drop(columns=['cohort_size'])
# в таблице динамики ltv оставляем только нужный лайфтайм
ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]
# стоимость привлечения запишем в отдельный фрейм
cac_history = roi_history[['cac']]
# из таблицы roi исключаем размеры когорт и cac
roi = roi.drop(columns=['cohort_size', 'cac'])
# в таблице динамики roi оставляем только нужный лайфтайм
roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
[horizon - 1]
]
# первый график — кривые ltv
ax1 = plt.subplot(2, 3, 1)
ltv.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('LTV')
# второй график — динамика ltv
ax2 = plt.subplot(2, 3, 2, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in ltv_history.index.names if name not in ['dt']]
filtered_data = ltv_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))
# третий график — динамика cac
ax3 = plt.subplot(2, 3, 3, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in cac_history.index.names if name not in ['dt']]
filtered_data = cac_history.pivot_table(
index='dt', columns=columns, values='cac', aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title('Динамика стоимости привлечения пользователей')
# четвёртый график — кривые roi
ax4 = plt.subplot(2, 3, 4)
roi.T.plot(grid=True, ax=ax4)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('ROI')
# пятый график — динамика roi
ax5 = plt.subplot(2, 3, 5, sharey=ax4)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in roi_history.index.names if name not in ['dt']]
filtered_data = roi_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax5)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.xlabel('Дата привлечения')
plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
После каждого пункта сформулируйте выводы.
# получаем профили пользователей
profiles = get_profiles(visits, orders, costs)
display(profiles.head(5))
# определяем минимальную дату привлечения пользователей
print('Минимальная дата привлечения пользователей:', profiles['first_ts'].dt.date.min())
# определяем максимальную дату привлечения пользователей
print('Максимальная дата привлечения пользователей:', profiles['first_ts'].dt.date.max())
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | FaceBoom | Mac | United States | 2019-05-07 | 5 | True | 1.09 |
| 1 | 4919697 | 2019-07-09 12:46:07 | FaceBoom | iPhone | United States | 2019-07-09 | 7 | False | 1.11 |
| 2 | 6085896 | 2019-10-01 09:58:33 | organic | iPhone | France | 2019-10-01 | 10 | False | 0.00 |
| 3 | 22593348 | 2019-08-22 21:35:48 | AdNonSense | PC | Germany | 2019-08-22 | 8 | False | 0.99 |
| 4 | 31989216 | 2019-10-02 00:07:44 | YRabbit | iPhone | United States | 2019-10-02 | 10 | False | 0.23 |
Минимальная дата привлечения пользователей: 2019-05-01 Максимальная дата привлечения пользователей: 2019-10-27
Минимальной датой привлечения пользователей является: 1 мая 2019 года.\ Максимальной датой привлечения пользователей является: 27 октября 2019 года.
# построим таблицу с количеством платящих и неплатящих пользователей
profiles_payer = (
profiles
.groupby('payer')
.agg({'user_id': 'count'})
)
(profiles_payer
.style
.background_gradient(cmap='Blues', axis=0)
)
| user_id | |
|---|---|
| payer | |
| False | 141127 |
| True | 8881 |
# построим круговую диаграмму по типам пользователей
profiles_payer.sort_values(by='user_id', ascending=False).plot(
kind='pie', y='user_id', figsize=(10, 10), autopct='%1.1f%%')
# Add labels
plt.title('Доля платящих и не платящих пользователей')
plt.ylabel('Тип пользователей')
plt.show()
Доля платящих пользователей составляет 5,9% от всех пользователей\ Доля не платящих пользователей составляет 94,1% от всех пользователей
# построим таблицу, отражающую количество пользователей и долю платящих из каждой страны
numeric_columns = ['user_id',
'payer']
profiles_region = (
profiles.groupby('region')
.agg({'user_id': 'nunique', 'payer': 'mean'})
.rename(columns={'user_id': 'cnt_users'})
.sort_values(by='payer', ascending=False)
)
(profiles_region
.style
.background_gradient(cmap='Blues', axis=0)
)
| cnt_users | payer | |
|---|---|---|
| region | ||
| United States | 100002 | 0.069019 |
| Germany | 14981 | 0.041119 |
| UK | 17575 | 0.039829 |
| France | 17450 | 0.037994 |
# построим круговую диаграмму по регионам пользователей
profiles_region.sort_values(by='payer', ascending=False).plot(
kind='pie', y='payer', figsize=(10, 10), autopct='%1.1f%%')
# Add labels
plt.title('Доля по регионам пользователей')
plt.ylabel('Регион')
plt.show()
Доля платящих пользователей по странам:
США лидирует по количеству общих пользователей - 100 тыс. человек с огромной долей 66,7%, а Германия находится на последнем месте - 15 тыс. человек, доля составляет - 10%.
# построим таблицу, отражающую количество пользователей и долю платящих
# для каждого устройства
profiles_device = (
profiles.groupby('device')
.agg({'user_id': 'nunique', 'payer': 'mean'})
.rename(columns={'user_id': 'cnt_users'})
.sort_values(by='payer', ascending=False)
)
(profiles_device
.style
.background_gradient(cmap='Blues', axis=0)
)
| cnt_users | payer | |
|---|---|---|
| device | ||
| Mac | 30042 | 0.063644 |
| iPhone | 54479 | 0.062079 |
| Android | 35032 | 0.058518 |
| PC | 30455 | 0.050468 |
# построим круговую диаграмму по типам устройств пользователей
profiles_device.sort_values(by='payer', ascending=False).plot(
kind='pie', y='payer', figsize=(10, 10), autopct='%1.1f%%')
# Add labels
plt.title('Доля по типам устройств пользователей')
plt.ylabel('Устройства')
plt.show()
Доля платящих пользователей по устройствам:
iPhone лидирует по количеству пользователей - 54 тыс. пользователей с общей долей - 36,3% почти в два раза больше чем у пользователей Mac.\ Хотя Mac и лидирует по доле платящих пользователей, общая доля пользователей меньше всех - 20%.
# построим таблицу, отражающую количество пользователей и долю платящих
# для каждого канала привлечения
profiles_channel = (
profiles.groupby('channel')
.agg({'user_id': 'nunique', 'payer': 'mean'})
.rename(columns={'user_id': 'cnt_users'})
.sort_values(by='payer', ascending=False)
)
(profiles_channel
.style
.background_gradient(cmap='Blues', axis=0)
)
| cnt_users | payer | |
|---|---|---|
| channel | ||
| FaceBoom | 29144 | 0.122049 |
| AdNonSense | 3880 | 0.113402 |
| lambdaMediaAds | 2149 | 0.104700 |
| TipTop | 19561 | 0.096007 |
| RocketSuperAds | 4448 | 0.079137 |
| WahooNetBanner | 8553 | 0.052964 |
| YRabbit | 4312 | 0.038265 |
| MediaTornado | 4364 | 0.035747 |
| LeapBob | 8553 | 0.030633 |
| OppleCreativeMedia | 8605 | 0.027077 |
| organic | 56439 | 0.020553 |
Канал FaceBoom лидирует по доле платящих пользователей (доля составляет 12,2%), также FaceBoom находится на 2м месте по общей доле пользователей (среди платящих и не платящих) - общая доля составляет 19,4% .\ Канал organic находится на последнем месте (доля составляет всего 2%), хотя общая доля пользователей (среди платящих и не платящих) больше всех - 37,6%, если учитывать тот факт, что эти пользователи сами нашли приложение Procrastinate Pro+ и компания не потратила денег на их привлечение, то можно сказать, что это неплохие показатели для данного канала.
Напишите промежуточные выводы.
costs.head()
| dt | channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.30 |
| 1 | 2019-05-02 | FaceBoom | 78.10 |
| 2 | 2019-05-03 | FaceBoom | 85.80 |
| 3 | 2019-05-04 | FaceBoom | 136.40 |
| 4 | 2019-05-05 | FaceBoom | 122.10 |
# находим общую сумму расходов на маркетинг
all_costs = (
costs
.agg({'costs': 'sum'})
)
all_costs
costs 105497.30 dtype: float64
Общая сумма расходов на маркетинг по всем каналам составляет 105,5 тыс. у.е.
# находим расходы на каждый рекламный источник
costs_channel = (
costs.groupby('channel')
.agg({'costs': 'sum'})
.sort_values(by='costs', ascending=False)
)
costs_channel
| costs | |
|---|---|
| channel | |
| TipTop | 54751.30 |
| FaceBoom | 32445.60 |
| WahooNetBanner | 5151.00 |
| AdNonSense | 3911.25 |
| OppleCreativeMedia | 2151.25 |
| RocketSuperAds | 1833.00 |
| LeapBob | 1797.60 |
| lambdaMediaAds | 1557.60 |
| MediaTornado | 954.48 |
| YRabbit | 944.22 |
Оставим только 2 источника, все остальные поменяем на Others.
costs_top_3 = costs
costs_top_3['channel'] = costs_top_3['channel'].str.replace('WahooNetBanner', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('AdNonSense', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('OppleCreativeMedia', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('RocketSuperAds', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('LeapBob', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('lambdaMediaAds', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('MediaTornado', 'Others')
costs_top_3['channel'] = costs_top_3['channel'].str.replace('YRabbit', 'Others')
# находим расходы на топ-3 рекламных источника
costs_top_3_channel = (
costs_top_3.groupby('channel')
.agg({'costs': 'sum'})
.sort_values(by='costs', ascending=False)
)
costs_top_3_channel
| costs | |
|---|---|
| channel | |
| TipTop | 54751.30 |
| FaceBoom | 32445.60 |
| Others | 18300.40 |
# построим круговую диаграмму по типам пользователей
(costs_top_3_channel
.sort_values(by='costs', ascending=False)
.plot(kind='pie', y='costs', figsize=(10, 10), autopct='%1.1f%%')
)
# Add labels
plt.title('Доля по каналам привлечения пользователей')
plt.ylabel('Канал привлечения')
plt.show()
На канал TipTop потратили больше всего денег 54,7 тыс. или 51,9% от всего бюджета.\ Далее на втором месте находится канал FaceBoom, на него было привлечено 32,4 тыс. или 30,8% от всего бюджета.\ На все оставшиеся каналы было привлечено менее 20% от всего бюджета.
# создадим отдельные колонки с номером недели и месяца
costs['week'] = pd.to_datetime(costs['dt']).dt.isocalendar().week
costs['month'] = pd.to_datetime(costs['dt']).dt.month
costs.head()
| dt | channel | costs | week | month | |
|---|---|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.30 | 18 | 5 |
| 1 | 2019-05-02 | FaceBoom | 78.10 | 18 | 5 |
| 2 | 2019-05-03 | FaceBoom | 85.80 | 18 | 5 |
| 3 | 2019-05-04 | FaceBoom | 136.40 | 18 | 5 |
| 4 | 2019-05-05 | FaceBoom | 122.10 | 18 | 5 |
# построим сводную таблицу по неделям
filter_week = costs.pivot_table(
index='week', columns='channel', values='costs', aggfunc='sum'
)
# построим график
filter_week.plot(grid=True, figsize=(12, 10))
# add labels
plt.xlabel('Неделя привлечения')
plt.ylabel('Расходы')
plt.title('Динамика изменения расходов по неделям по каждому источнику')
plt.show()
Построим график с ограничением по вертикальной оси для удобства визуализации источников с малыми расходами.
# построим сводную таблицу по неделям
filter_week = costs.pivot_table(
index='week', columns='channel', values='costs', aggfunc='sum'
)
# построим график с ограничением по вертикальной оси
filter_week.plot(grid=True, figsize=(12, 10), ylim=(0, 340))
# add labels
plt.xlabel('Неделя привлечения')
plt.ylabel('Расходы')
plt.title('Динамика изменения расходов по неделям по каждому источнику')
plt.show()
# построим сводную таблицу по месяцам
filter_dat = costs.pivot_table(
index='month', columns='channel', values='costs', aggfunc='mean'
)
# построим график
filter_dat.plot(grid=True, figsize=(12, 10))
# add labels
plt.xlabel('Месяц привлечения')
plt.ylabel('Расходы')
plt.title('Динамика изменения расходов по месяцам по каждому источнику')
plt.show()
Построим график с ограничением по вертикальной оси для удобства визуализации источников с малыми расходами.
# построим сводную таблицу по месяцам
filter_dat = costs.pivot_table(
index='month', columns='channel', values='costs', aggfunc='mean'
)
# построим график с ограничением пол вертикальной оси
filter_dat.plot(grid=True, figsize=(12, 10), ylim=(0, 80))
# add labels
plt.xlabel('Месяц привлечения')
plt.ylabel('Расходы')
plt.title('Динамика изменения расходов по месяцам по каждому источнику')
plt.show()
Взглянув на графики динамики изменения расходов по неделям и месяцам видно, что расходы на каналы TipTop и FaceBoom значительно увеличиваются по сравнению с остальными каналами.
# находим среднюю стоимость привлечения одного пользователя (CAC)
# из каждого источника
cac = (
profiles.groupby('channel')
.agg({'acquisition_cost': 'mean'})
.rename(columns={'acquisition_cost': 'cac'})
.sort_values(by='cac', ascending=False)
)
cac
| cac | |
|---|---|
| channel | |
| TipTop | 2.80 |
| FaceBoom | 1.11 |
| AdNonSense | 1.01 |
| lambdaMediaAds | 0.72 |
| WahooNetBanner | 0.60 |
| RocketSuperAds | 0.41 |
| OppleCreativeMedia | 0.25 |
| YRabbit | 0.22 |
| MediaTornado | 0.22 |
| LeapBob | 0.21 |
| organic | 0.00 |
Средняя стоимость привлечения одного пользователя из канала TipTop обошлась компании в 2,8 у.е., из FaceBoom - 1,11 у.е., AdNonSense - 1 у.е.
Используя графики LTV, ROI и CAC, проанализируйте окупаемость рекламы. Считайте, что на календаре 1 ноября 2019 года, а в бизнес-плане заложено, что пользователи должны окупаться не позднее чем через две недели после привлечения. Необходимость включения в анализ органических пользователей определите самостоятельно.
Напишите вывод, опишите возможные причины обнаруженных проблем и промежуточные рекомендации для рекламного отдела.
Стоит исключить органический трафик,так как мы не платим за этих пользователей, а доход от них может завышать показатели и скрыть от нас проблемы в какой-то стране или же типе устройств.
# исключаем органический трафик
profiles = profiles.query('channel != "organic"')
profiles
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | FaceBoom | Mac | United States | 2019-05-07 | 5 | True | 1.09 |
| 1 | 4919697 | 2019-07-09 12:46:07 | FaceBoom | iPhone | United States | 2019-07-09 | 7 | False | 1.11 |
| 3 | 22593348 | 2019-08-22 21:35:48 | AdNonSense | PC | Germany | 2019-08-22 | 8 | False | 0.99 |
| 4 | 31989216 | 2019-10-02 00:07:44 | YRabbit | iPhone | United States | 2019-10-02 | 10 | False | 0.23 |
| 7 | 46006712 | 2019-06-30 03:46:29 | AdNonSense | Android | France | 2019-06-30 | 6 | True | 1.01 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 149999 | 999942424543 | 2019-05-24 21:03:26 | TipTop | iPhone | United States | 2019-05-24 | 5 | False | 1.90 |
| 150001 | 999949280903 | 2019-08-12 06:47:27 | FaceBoom | Mac | United States | 2019-08-12 | 8 | False | 1.12 |
| 150003 | 999956196527 | 2019-09-28 08:33:02 | TipTop | iPhone | United States | 2019-09-28 | 9 | False | 3.50 |
| 150005 | 999976332130 | 2019-07-23 02:57:06 | TipTop | iPhone | United States | 2019-07-23 | 7 | False | 2.60 |
| 150006 | 999979924135 | 2019-09-28 21:28:09 | MediaTornado | PC | United States | 2019-09-28 | 9 | False | 0.21 |
93569 rows × 9 columns
observation_date = datetime(2019, 11, 1).date() # момент анализа
horizon_days = 14 # горизонт анализа
# считаем LTV и ROI
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days
)
# строим графики
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
По графикам можно сделать такие выводы:
Cтабильность LTV и рост CAC, может говорить о росте конкуренции на канале или увеличением бюджета по каналу.
Чтобы разобраться в причинах, пройдём по всем доступным характеристикам пользователей — разложим на частные причины и декомпозируем весь трафик по странам, устройствам, регионам и поищем где конкретно и в какой момент случилась проблема.
# смотрим конверсию
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
Судя по графикам, пользователи конвертируются хорошо, причём постоянно.
# смотрим удержание с разбивкой на платящих и неплатящих пользователей
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days
)
plot_retention(retention_grouped, retention_history, horizon_days)
Удержание пользователей тоже в пределах нормы, кроме удержания неплатящих пользователей - в конце второй недели они близки к нулю.
# смотрим окупаемость с разбивкой по странам
dimensions = ['region']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
# смотрим удержание с разбивкой на платящих и неплатящих по странам
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
# смотрим конверсию с разбивкой по устройствам
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По графикам можно сделать такие выводы:
Стоит подробнее рассмотреть на всех ли каналах, которые приводят трафик в США, такая ситуация.
# смотрим окупаемость с разбивкой по устройствам
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
# смотрим удержание с разбивкой на платящих и неплатящих по устройствам
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
# смотрим конверсию с разбивкой по устройствам
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По графикам можно сделать такие выводы:
# смотрим окупаемость с разбивкой по источникам привлечения
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
# смотрим удержание с разбивкой на платящих и неплатящих по источникам привлечения
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
# смотрим конверсию с разбивкой по устройствам
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По графикам можно сделать такие выводы:
Разберём отдельно страну США, т.к. именно по ним были самые большие вливания в рекламу и худшие показатели по удержанию.
# Возмём разрез датасета только по стране США
profiles_usa = profiles[profiles['region'] == 'United States']
profiles_usa
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | FaceBoom | Mac | United States | 2019-05-07 | 5 | True | 1.09 |
| 1 | 4919697 | 2019-07-09 12:46:07 | FaceBoom | iPhone | United States | 2019-07-09 | 7 | False | 1.11 |
| 4 | 31989216 | 2019-10-02 00:07:44 | YRabbit | iPhone | United States | 2019-10-02 | 10 | False | 0.23 |
| 8 | 58263264 | 2019-05-11 19:18:43 | FaceBoom | iPhone | United States | 2019-05-11 | 5 | False | 1.13 |
| 9 | 59835779 | 2019-05-11 21:13:47 | MediaTornado | iPhone | United States | 2019-05-11 | 5 | False | 0.25 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 149999 | 999942424543 | 2019-05-24 21:03:26 | TipTop | iPhone | United States | 2019-05-24 | 5 | False | 1.90 |
| 150001 | 999949280903 | 2019-08-12 06:47:27 | FaceBoom | Mac | United States | 2019-08-12 | 8 | False | 1.12 |
| 150003 | 999956196527 | 2019-09-28 08:33:02 | TipTop | iPhone | United States | 2019-09-28 | 9 | False | 3.50 |
| 150005 | 999976332130 | 2019-07-23 02:57:06 | TipTop | iPhone | United States | 2019-07-23 | 7 | False | 2.60 |
| 150006 | 999979924135 | 2019-09-28 21:28:09 | MediaTornado | PC | United States | 2019-09-28 | 9 | False | 0.21 |
61829 rows × 9 columns
Построим графики для анализа окупаемости рекламы с разбивкой по утройствам и рекламным каналам.
# смотрим окупаемость с разбивкой по устройствам
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_usa, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
# смотрим удержание с разбивкой на платящих и неплатящих по устройствам
retention_raw, retention_grouped, retention_history = get_retention(
profiles_usa, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
# смотрим конверсию с разбивкой по устройствам
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles_usa, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По графикам можно сделать такие выводы:
# смотрим окупаемость с разбивкой по источникам привлечения
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_usa, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
# смотрим удержание с разбивкой на платящих и неплатящих по источникам привлечения
retention_raw, retention_grouped, retention_history = get_retention(
profiles_usa, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
# смотрим конверсию с разбивкой по источникам привлечения
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles_usa, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По графикам можно сделать те же выводы, которые были уже сделаны по графикам, где были рассмотрены все страны. Самые проблемные те же 2 канала: TipTop и FaceBoom.
# Посчитаем общее количество пользователей из США
users_usa = profiles_usa['user_id'].nunique()
users_usa
61829
В США 61,8 тыс пользователей.
# находим количество привлеченных пользователей с каждого рекламного источника из США
cnt_users_usa = (
profiles_usa.groupby('channel')
.agg({'user_id': 'count'})
.sort_values(by='user_id', ascending=False)
)
cnt_users_usa
| user_id | |
|---|---|
| channel | |
| FaceBoom | 29144 |
| TipTop | 19561 |
| RocketSuperAds | 4448 |
| MediaTornado | 4364 |
| YRabbit | 4312 |
# построим круговую диаграмму по каналам привлечения из США
(cnt_users_usa
.plot(kind='pie', y='user_id', figsize=(10, 10), autopct='%1.1f%%')
)
# Add labels
plt.title('Доля по каналам привлечения пользователей')
plt.ylabel('Канал привлечения')
plt.show()
Пользователи из США привлеченные с канала FaceBoom составляет 47,1% от общего количества пользователей из США. Хочу напомнить, что расходы в данный канал составляют долю 30,8% от всего бюджета или 32,4 тыс. Далее идёт канал TipTop с долей в 31,6% от общего количества пользователей из США. В данный канал было вложено 51,9% от всего бюджета или 54,7 тыс.
Данные два канала занимают более 80% всего бюджета расходов, а показатели намного хуже чем у остальных каналов.
Далее нужно выяснить какие страны ещё затрагивают данные 2 канала.
print('Общее количество пользователей привлеченных по каналу FaceBoom:',
profiles[profiles['channel'] == 'FaceBoom']['channel'].count())
print('Количество пользователей из США привлеченных по каналу FaceBoom:',
profiles_usa[profiles_usa['channel'] == 'FaceBoom']['channel'].count())
Общее количество пользователей привлеченных по каналу FaceBoom: 29144 Количество пользователей из США привлеченных по каналу FaceBoom: 29144
Канал привлечения FaceBoom полностью ориентирован на США - все привлеченные пользователи из США.
print('Общее количество пользователей привлеченных по каналу TipTop:',
profiles[profiles['channel'] == 'TipTop']['channel'].count())
print('Количество пользователей из США привлеченных по каналу TipTop:',
profiles_usa[profiles_usa['channel'] == 'TipTop']['channel'].count())
Общее количество пользователей привлеченных по каналу TipTop: 19561 Количество пользователей из США привлеченных по каналу TipTop: 19561
Канал привлечения TipTop полностью ориентирован на США - все привлеченные пользователи из США.
Таким образом стало понятно, что оба канала ориентированы только на США, и теперь стало понятно почему США так сильно выделяется среди остальных стран по доле выделенного бюджета.
Ниже я напишу общий вывод и свои рекомендации по проведенному исследованию.
Я проанализировал данные по пользователям приложения Procrastinate Pro+ по странам (США, Великобритания, Франция и Германия), по устройствам (iPhone, Mac, Android, PC), по каналам привлечения и сделал следующие выводы:
США находится на первом месте по количеству пользователей (100 тыс. человек), что составляет 66,7% и существенно обгоняет все остальные страны. При этом доля платящих пользователей - 6,9%.\ По графикам видно, что затраты на рекламу окупаются во всех странах, кроме США, что связано с большими вливаниями (которые занимают 80% от всех расходов) в 2 канала: TipTop и FaceBoom.\ Платящие пользователи из США плохо удерживаются, а конверсия у пользователей из США наоборот выше, чем у пользователей из остальных стран, примерно на 40%.
iPhonе опережает среди устройств, доля которого составляет - 36%. Далее идут пользователи Android, доля которых составляет 23%, пользователи PC (20%), Mac (20%).\ Затраты на рекламу окупаются только у пользователей Android PC (ROI - 105%), но конверсия у них хуже - на 14-й день примерно на 15% ниже чем у пользователей с остальных девайсов.
Органические пользователи лидируют, доля составляет 37,6% от общего количества пользователей, от платящих пользователей - 2%. Канал TipTop является самым расходным каналом, доля которого составляет 19,4% от общего количества пользователей и 12% от платящих пользователей, привлечение одного пользователя обошлось в среднем в 2,76 у.е.\ Затраты на рекламу окупаются у всех каналов, кроме трех: AdNonSense (ROI - 90%), FaceBoom (ROI - 85%), TipTop (ROI - 60%). У платных пользователей из каналов AdNonSense и FaceBoom - худшие показатели удержания и лучшие показатели конверсии.
Проанализировав данные, можно сказать, что реклама в привлечение пользователей по основным каналам привлечения (TipTop, FaceBoom) является нерентабельным. Основной проблемой является огромное увеличение расходов на привлечение пользователей по указанным каналам.\ Отделу маркетинга рекомендуется сократить излишние расходы на пользователей из США, каналы TipTop, FaceBoom и на девайсы Apple (iPhone и Mac). Следует направить освободившийся бюджет на более перспективные каналы и страны.\ Например, в разрезе по устройствам расходы окупились в основном у пользователей PC, в разрезе по странам - у пользователей из Франции, Германии и Великобритании. ROI 7 каналов из 10 оказались выше уровня окупаемости.